WebSockets with AWS Lambda
Fanout Cloud handles long-lived connections, such as HTTP streaming and WebSocket connections, on behalf of API backends. For projects that need to push data at scale, this can be a smart architecture. It also happens to be handy with function-as-a-service backends, such as AWS Lambda, which are not designed to handle long-lived connections on their own. By combining Fanout Cloud and Lambda, you can build serverless realtime applications.
Of course, Lambda can integrate with services such as AWS IoT to achieve a similar effect. The difference with Fanout Cloud is that it works at a lower level, giving you access to raw protocol elements. For example, Fanout Cloud enables you to build a Lambda-powered API that supports plain WebSockets, which is not possible with any other service.
To make integration easy, we’ve introduced FaaS libraries for Node.js and Python. Read on to learn how it all works.
An example
Let’s jump right into an example. The following Node.js code implements a WebSocket echo service:
var grip = require('grip');
var faas_grip = require('faas-grip');
exports.handler = function (event, context, callback) {
var ws;
try {
ws = faas_grip.lambdaGetWebSocket(event);
} catch (err) {
callback(null, {
statusCode: 400,
headers: {'Content-Type': 'text/plain'},
body: 'Not a WebSocket-over-HTTP request\n'
});
return;
}
// if this is a new connection, accept it
if (ws.isOpening()) {
ws.accept();
}
// here we loop over any messages
while (ws.canRecv()) {
var message = ws.recv();
// if return value is null, then the connection is closed
if (message == null) {
ws.close();
break;
}
// echo the message
ws.send(message);
}
callback(null, ws.toResponse());
};
The lambdaGetWebSocket
method returns a WebSocketContext
object which has a socket-like API. You can call methods on it such as accept()
, send()
, recv()
, and close()
. This makes it possible to write code that almost looks like normal socket handling code.
Don’t be fooled by the while
loop; at first glance it might look like it runs for the lifetime of the WebSocket connection, but it really only runs while processing each batch of incoming events.
In the next two sections we’ll explain the magic going on behind the scenes.
WebSocket-over-HTTP protocol
When Fanoud Cloud receives a WebSocket connection from a client, it converts events from that connection into a series of HTTP requests and sends them to a configured backend server. Events are encoded in the HTTP request body using the application/websocket-events
content type. The backend can respond with events to the WebSocket client by encoding events in the HTTP response.
Below are some example exchanges. The format is inspired by HTTP chunked encoding. Note that the characters \r\n
represent a two-byte carriage return and newline sequence. Linebreaks are also inserted for readability.
Fanout tells the backend server about a new connection:
POST /target HTTP/1.1
Connection-Id: b5ea0e11
Content-Type: application/websocket-events
OPEN\r\n
Backend server accepts connection:
HTTP/1.1 200 OK
Content-Type: application/websocket-events
OPEN\r\n
Fanout relays message from client:
POST /target HTTP/1.1
Connection-Id: b5ea0e11
Content-Type: application/websocket-events
TEXT 5\r\n
hello\r\n
Backend server responds with two messages:
HTTP/1.1 200 OK
Content-Type: application/websocket-events
TEXT 5\r\n
world\r\n
TEXT 1B\r\n
here is another nice message\r\n
For details, see the spec. Note that it’s not necessary to completely understand the protocol since our libraries take care of it for you, through the WebSocketContext
object.
WebSocketContext
Our Node.js and Python libraries each provide a socket-like object called WebSocketContext
that handles the event marshalling over HTTP. The object contains methods like accept()
, send()
, recv()
, etc. What’s interesting is that these methods don’t operate directly on a real WebSocket. When recv()
is called, it simply iterates over the events received in the current HTTP request. When send()
is called, events are temporarily enqueued, and later serialized into the HTTP response. WebSocketContext
objects are not long-lived, and a fresh one is created for each handler invocation and destroyed afterwards.
Now that you know how the WebSocket-over-HTTP protocol and WebSocketContext
object work, we can explain the earlier example code. Recall this part:
// if this is a new connection, accept it
if (ws.isOpening()) {
ws.accept();
}
What happens here is isOpening()
returns true
if an OPEN
event was received in the current request. The accept()
call sets a flag on the object that it should include an OPEN
event when it responds.
And here’s the main message loop:
// here we loop over any messages
while (ws.canRecv()) {
var message = ws.recv();
// if return value is null, then the connection is closed
if (message == null) {
ws.close();
break;
}
// echo the message
ws.send(message);
}
The canRecv()
call returns true
if TEXT
, BINARY
, or CLOSE
events were received in the current request, and they haven’t been read yet using recv()
. When there are no more events to read in the batch, canRecv()
returns false
and the loop exits. The send()
method enqueues an outgoing message into the WebSocketContext
.
It’s important to note here that canRecv()
returning false
does not mean the physical WebSocket connection has closed. It only means there are no more events to read in the current request. The loop can exit and the Lambda function can terminate, and Fanout Cloud will keep the WebSocket connection open with the client.
Finally, the last part of the function:
callback(null, ws.toResponse());
The toResponse()
method generates an HTTP response to send back to Fanout Cloud, containing WebSocket events. If the context was flagged using accept()
, then an OPEN
event will be included. If send()
and/or close()
were called, then TEXT
and/or CLOSE
events will be included as appropriate. Fanout Cloud will then receive these events from the Lambda function, and translate them into native WebSocket communication with the client.
Pushing data
So far we’ve discussed how to accept incoming WebSocket connection requests and respond to incoming messages, but the main reason to use WebSockets is to have the ability to push data! This is done using publish/subscribe messaging between Fanout Cloud and a backend publisher.
To subscribe a WebSocket connection to a channel, call subscribe()
:
ws.subscribe('mychannel');
Data can then be published to subscribed connections like this:
faas_grip.publish('mychannel', new grip.WebSocketMessageFormat('some data'));
Note that the WebSocket client has no awareness of the publish/subscribe layer. The channel schema is private between Fanout Cloud and your backend, and clients don’t directly subscribe to channels.
Connection metadata
The Lambda function terminates after processing each set of events, making it seem like stateful APIs would be impossible to implement. However, Fanout Cloud and its libraries make this really easy to do via the metadata feature.
For example, suppose you have a WebSocket API with an authentication step. You could do something like this:
var msg = JSON.parse(ws.recv());
if (msg.type == 'auth') {
if (authCheck(msg.username, msg.password)) {
// apply 'username' to the metadata
ws.meta.username = msg.username;
} else {
ws.send(JSON.stringify({
'type': 'error',
'reason': 'auth-failed'
}));
}
} else if (msg.type == 'do-thing') {
if (ws.meta.username) {
// if user was previously authorized, perform the action
doThingAs(ws.meta.username);
} else {
ws.send(JSON.stringify({
'type': 'error',
'reason': 'not-authorized'
}));
}
}
Any values set on the meta
property of the WebSocketContext
will be sent back to Fanout Cloud and preserved across invocations.
Chat example
Tying it all together, here’s an example of a chat service that allows clients to connect, set a nickname by sending an IRC-like /nick
message, and send messages to other connected clients:
var grip = require('grip');
var faas_grip = require('faas-grip');
exports.handler = function (event, context, callback) {
var ws;
try {
ws = faas_grip.lambdaGetWebSocket(event);
} catch (err) {
callback(null, {
statusCode: 400,
headers: {'Content-Type': 'text/plain'},
body: 'Not a WebSocket-over-HTTP request\n'
});
return;
}
// if this is a new connection, accept it and subscribe it to a channel
if (ws.isOpening()) {
ws.accept();
ws.subscribe('room');
}
// here we loop over any messages
while (ws.canRecv()) {
var message = ws.recv();
// if return value is null, then the connection is closed
if (message == null) {
ws.close();
break;
}
if (message.startsWith('/nick ')) {
var nick = message.substring(6);
ws.meta.nick = nick;
ws.send('nickname set to [' + nick + ']');
} else {
// send the message to all clients
var nick = ws.meta.nick || 'anonymous';
faas_grip.publish(
'room',
new grip.WebSocketMessageFormat(nick + ': ' + message)
);
}
}
callback(null, ws.toResponse());
};
This is a realtime WebSocket API driven by a Lambda function! Did we mention it also scales?
HTTP streaming and long-polling
In addition to WebSockets, long-lived HTTP connections are also supported. See the library documentation for details.
Implement WebSockets on Lambda today
Fanout Cloud and AWS Lambda are a powerful combination. Check out the library for your preferred environment (Node.js, Python), and get started for free.
Recent posts
-
We've been acquired by Fastly
-
A cloud-native platform for push APIs
-
Vercel and WebSockets
-
Rewriting Pushpin's connection manager in Rust
-
Let's Encrypt for custom domains